愛知県の市区町村当てクイズをPythonライブラリを活用して作ってみた
はじめに
私は今Pythonを勉強しており、最近はpandasやturtleと格闘しています。せっかくなのでこれらのライブラリを使って何か作ってみようと思い、愛知県の市区町村名を当てるクイズアプリを作ることにしました。
開発環境
- Windows 10 Pro 64bit
- Python 3.12.1
- pandas、Pillowをインストール済みとします
成果物
出来上がったものは以下のようなクイズアプリです。
起動すると愛知県の地図とダイアログが表示されます。
愛知県に存在する市区町村名を入力します。
正解すれば、その市区町村の名前が地図に表示されます。
全市区町村を当てるまで頑張ります。
なお、このアプリのアイデアについては以下のUdemy講座で取り上げられていた、アメリカの州を当てるクイズを参考にしています。
100 Days of Code: The Complete Python Pro Bootcamp
元ネタはこちらみたいです。
データの作成
このアプリでは、市区町村名を入力されたら、該当の市区町村の位置に名前を表示させます。表示させるのはturtleで行いますが、その際にスクリーン上の座標を指定する必要があります。
しかし単なる地図の画像に対して、どの市区町村がどの座標なのかを直接特定することはできません。
そこで、以下のようにすれば可能ではないかと考えました。
- 愛知県の北端、南端、東端、西端がスクリーンにちょうど収まるようにし、スクリーンの上下左右の端に対応する経緯度を取得し、経緯度をスクリーンの座標に変換する
- 各市区町村の名前は、各市区町村の中心の経緯度に対応するスクリーン座標に表示させる
そのためには、「愛知県がちょうど収まっている地図」、「北端、南端、東端、西端の経緯度」、「各市区町村の中心の経緯度」という3つのデータが必要になります。以下、必要なデータを用意します。
白地図の用意
地図は国土地理院の地理院地図というWebサイトからダウンロードしました。
愛知県が表示されるようにして、右上の「共有」から画像のアイコンをクリックします。
「範囲を固定」を選択すると、範囲を選択できる赤枠が表示されるので、ちょうど東西南北が収まるように選択します。
「OK」をクリックすると以下のようなダイアログが表示されるので、「画像を保存」で保存します。
保存した画像には市区町村名が表示されています。探してみたのですが、表示を消した状態でダウンロードする機能は見当たりませんでした。そこで、市区町村名についてはダウンロードした後に手で削除しました。
これで白地図の用意は完了です。
市区町村の位置情報の取得
愛知県の東西南北や、各市区町村の経緯度をどのように取得しようかと調べていたところ、国土交通省の国土数値情報ダウンロードサイトというWebサイトを発見しました。
ここで各市区町村の経緯度の情報がダウンロードできます。
この位置情報を使えば東西南北端や各市区町村の中心のおおよその経緯度が計算できるのではないかと考えました。
早速データをダウンロードします。
メニューの「位置参照情報」⇒「データダウンロード」をクリックします。
ダウンロード方式を選択する画面が表示されるので、「都道府県単位」をクリックします。
愛知県にチェックを入れ、「選択」をクリックします。
レベルに「大字・町丁目」と「街区」がありますが「街区」の方が「〇〇丁目〇〇番」のレベルまで掲載されておりより細かいため、今回は「街区」の方をダウンロードします。
ダウンロードしたデータを確認したところ、なぜか「北設楽郡設楽町」など一部の市区町村が含まれていませんでした。そこで、含まれていない市区町村については、「ダウンロード方式選択」の画面から「市区町村単位」を選択し、個別にダウンロードしました。
ダウンロードしたCSVファイルの中身は以下のようになっています。
"都道府県名","市区町村名","大字・丁目名","小字・通称名","街区符号・地番","座標系番号","X座標","Y座標","緯度","経度","住居表示フラグ","代表フラグ","更新前履歴フラグ","更新後履歴フラグ" "愛知県","名古屋市千種区","花田町一丁目","","35","7","-93250.7","-22274.7","35.159203","136.922164","0","1","0","0" "愛知県","名古屋市千種区","吹上一丁目","","1","7","-93156.5","-22310.7","35.160052","136.921766","1","1","0","0" "愛知県","名古屋市千種区","吹上一丁目","","2","7","-93169.8","-22233.7","35.159933","136.922611","1","1","0","0" "愛知県","名古屋市千種区","千種二丁目","","21","7","-92989.7","-22167.9","35.161559","136.923329","1","1","0","0" "愛知県","名古屋市千種区","吹上一丁目","","7","7","-93316.4","-22120.0","35.158614","136.923863","1","1","0","0" (以下略)
このデータを使って、「北端、南端、東端、西端の経緯度」、「各市区町村の中心の経緯度」のデータを作成します。
データを整える
プロジェクトファイルにdata_creator.py
というPythonファイルとdata
フォルダを作成し、data
フォルダの中にinput
フォルダ、output
フォルダを作成します。
data_creator.py
にデータを作成する処理を記述していきます。このプログラムはクイズアプリで使うデータを作るためのもので、クイズアプリ本体では使いません。
まず、data/input
フォルダにダウンロードしたCSVファイルを格納します。今回の場合、都道府県単位のデータと市区町村単体のデータで計4ファイルが格納されています。
それらのデータを読み込み、一つのDataFrameに統合します。また、必要な情報は「市区町村名」、「緯度」、「経度」のみなので、統合する際にそれらの列のみピックアップするようにします。
指定したフォルダ内のCSVデータを読み込み、必要な列のみに限定した上でデータを統合する関数は以下のようにしました。
コメントで書いていますが、複数のDataFrameをconcat
で統合する際にはインデックスに注意する必要があります。pandasはCSVを読み込むときに自動的にインデックスを振りますが、concat
で統合すると各CSVに対して0からインデックスが振られるので、インデックスが重複します。重複すると、idxmax
などでインデックスを扱う際に不都合が起きることがあります。
そこで、concat
で統合する際にignore_index
をTrueにして回避します。
def load_and_unify_data(directory: str): # ディレクトリ内のCSVファイルを読み込む files = glob.glob(os.path.join(directory, "*.csv")) # 必要な列のみ取得 dataframes = [ pandas.read_csv(file, usecols=["市区町村名", "緯度", "経度"]) for file in files ] # 各データを統合する # ignore_indexをTrueにしないと各ファイルで重複したインデックスが発生してしまいidxmaxなどを使う際に不具合が起きる unified_data = pandas.concat(dataframes, ignore_index=True) return unified_data
この関数を実行すると以下のようなDataFrameが作成されます。
市区町村名 緯度 経度 0 名古屋市千種区 35.159203 136.922164 1 名古屋市千種区 35.160052 136.921766 2 名古屋市千種区 35.159933 136.922611 3 名古屋市千種区 35.161559 136.923329 4 名古屋市千種区 35.158614 136.923863 ... ... ... ... 1989143 北設楽郡豊根村 35.135472 137.761053 1989144 北設楽郡豊根村 35.200448 137.679079 1989145 北設楽郡豊根村 35.170053 137.711470 1989146 北設楽郡豊根村 35.184316 137.781940 1989147 北設楽郡豊根村 35.194143 137.743787
早速、このデータから境界や市区町村の中心の経緯度の取得を行いたいところですが、データを見てみると、いくつか問題があります。
- 名古屋市のデータが「名古屋市中村区」など区ごとになっている
- 「北設楽郡設楽町」など郡の名前も含まれている
白地図上は名古屋市は区に分かれていませんし、市町村名を当てるクイズで郡から答えるのは少し大変です。
そこで、データをフォーマットする関数を作成します。
# データを扱いやすい形式にフォーマットする def format(data: pandas.DataFrame): # # 名古屋市の各区のデータは名古屋市に統一する # ただし北名古屋市はそのままにする data.loc[data["市区町村名"].str.contains("^名古屋市"), "市区町村名"] = "名古屋市" # 郡は除外する # ただし蒲郡市はそのままにする data["市区町村名"] = data["市区町村名"].str.replace( "(.+?)(?<!蒲)郡", "", regex=True ) return data
名古屋市に属するデータはすべて市区町村名を「名古屋市」に統一します。ただし、「北名古屋市」はそのままにしたいので、^名古屋市
のような正規表現を使いました。
また、郡については「郡」以前の文字列をreplace
で削除します。ただし、「蒲郡市」はそのままにしたいので、「郡」の前が「蒲」である場合を除くようにしています。
この関数を実行すると以下のようなDataFrameが作成されます。
市区町村名 緯度 経度 0 名古屋市 35.159203 136.922164 1 名古屋市 35.160052 136.921766 2 名古屋市 35.159933 136.922611 3 名古屋市 35.161559 136.923329 4 名古屋市 35.158614 136.923863 ... ... ... ... 1989143 豊根村 35.135472 137.761053 1989144 豊根村 35.200448 137.679079 1989145 豊根村 35.170053 137.711470 1989146 豊根村 35.184316 137.781940 1989147 豊根村 35.194143 137.743787
これでようやく、データを作成する前にデータを整えるための関数ができました。
北端、南端、東端、西端の経緯度データの作成
各端の経緯度を取得する関数は以下の通りです。
# 東西南北端の経緯度を取得 def get_boundary_coordinates(data: pandas.DataFrame): # 緯度が最も北の行を取得 north = data.loc[data["緯度"].idxmax()].copy() north["方角"] = "N" # 緯度が最も南の行を取得 south = data.loc[data["緯度"].idxmin()].copy() south["方角"] = "S" # 経度が最も東の行を取得 east = data.loc[data["経度"].idxmax()].copy() east["方角"] = "E" # 経度が最も西の行を取得 west = data.loc[data["経度"].idxmin()].copy() west["方角"] = "W" return pandas.concat([north, south, east, west], axis=1).T
まず、DataFrameの中で最も緯度が大きいデータのインデックスをdata[”緯度”].idxmax()
で取得します。DataFrameのloc属性は、ラベルベースで行や列にアクセスするための方法です。idxmax
で取得したインデックスを使って、該当する行を取得します。
その後、各DataFrameに新たな「方角」という列を追加し、方角を示すアルファベットを値として設定しています。
なお、「方角」列に値を設定するところでA value is trying to be set on a copy of a slice from a DataFrame
という警告が発生したため、回避するためにcopy()
を使っています。この警告は元のDataFrameが変更される可能性を示すもののようです。
最後に東西南北のデータを一つのDataFrameにまとめて返しています。axis=1
は各データを水平方向に結合することを指定します。また、.T
を使って行と列を入れ替えています。
この関数を実行すると以下のようなDataFrameが作成されます。
市区町村名 緯度 経度 方角 1222001 犬山市 35.420765 136.968171 N 1664042 田原市 34.580968 137.039219 S 1989146 豊根村 35.184316 137.78194 E 1673513 愛西市 35.141424 136.674636 W
各市区町村の中心の経緯度データの作成
続いて各市区町村の中心の経緯度を取得します。これは、ユーザが正しい市区町村名を入力した際に、地図上のどの位置に名前を表示するかを決定するために使用します。
中心の経緯度は、単純に同一市区町村に属するデータの緯度と経度それぞれの平均を取ることにします。球面での情報を平面にマッピングするので、厳密にはもっと複雑な計算が必要になると思いますが、今回は文字の表示位置を決めたいだけなので簡単にできる方法を採用します。
各市区町村の中心の経緯度を求める関数は以下の通りです。
# 各市町村の中心の経緯度を取得 def get_center_coordinates(data: pandas.DataFrame): center_coordinates = ( data.groupby("市区町村名")[["緯度", "経度"]].mean().reset_index() ) return center_coordinates
DataFrameを市区町村名でグルーピングし、緯度、経度に対してmean()
で平均を取ります。
この関数を実行すると以下のようなDataFrameが作成されます。
市区町村名 緯度 経度 0 あま市 35.187393 136.803339 1 みよし市 35.089443 137.083929 2 一宮市 35.314737 136.795117 3 刈谷市 35.001335 137.011129 4 北名古屋市 35.247671 136.872479 (以下略)
データ出力
最後にここまでで作成した関数を呼び出して、アプリに使うためのCSVデータを作成します。CSVデータはdata/outputフォルダに格納されます。
# ファイルを読み込みデータを整える unified_data = load_and_unify_data("./data/input") formatted_data = format(unified_data) # 東西南北端を取得 boundaries = get_boundary_coordinates(formatted_data) boundaries.to_csv("./data/output/boundary_coordinates.csv", index=False) # 各市区町村の中心を取得 center_coordinates = get_center_coordinates(formatted_data) center_coordinates.to_csv("./data/output/center_coordinates.csv", index=False)
以下のコマンドで実行します。
python data_creator.py
boundary_coordinates.csv
の中身は以下のようになります。
市区町村名,緯度,経度,方角 犬山市,35.420765,136.968171,N 田原市,34.580968,137.039219,S 豊根村,35.184316,137.78194,E 愛西市,35.141424,136.674636,W
center_coordinates.csv
の中身は以下のようになります。
市区町村名,緯度,経度 あま市,35.18739292927157,136.80333930782066 みよし市,35.0894425453031,137.08392905312357 一宮市,35.3147366803221,136.79511724450654 刈谷市,35.00133530506926,136.991128622458 北名古屋市,35.22767077984681,136.87247922498725 半田市,34.90320217092687,136.93221882988263 (以下略)
これで、data_creator.py
とinput
フォルダの役目は終わりです。
クイズアプリの作成
ようやく本番であるアプリの実装に入ります。data_creator.py
と同じ階層にmain.py
を作成します。以下のコードはmain.py
に書いていきます。
まずは経緯度をスクリーン座標に変換する処理を作成する必要があります。
単純に考えるために、仮にスクリーンの高さが770px、スクリーンの上端を北緯36度、スクリーンの下端を北緯32度だとします。
この場合、770 / (36 - 32)という計算で、緯度1度あたりのスクリーンのpx数がわかります。この場合は192.5pxです。もしも北緯35度のスクリーン上の位置を求めたければ、36度の位置から所定のpx数だけ下に移動させるという計算になります。つまり、770pxから(36 - 35) * 770 / (36 - 32)pxだけ下に移動すれば良いことになります。
ただし、今回はturtleを使用します。turtleはスクリーンの中心が(0, 0)になります。そのため、スクリーンサイズが770pxだとしたら実際の座標は半分になるので、385pxの位置から下に移動します。
よって、緯度は以下のように取得できます。
(スクリーンサイズ / 2) - (上端の緯度 - 対象地点の緯度) * スクリーンサイズ / (上端の緯度 - 下端の緯度)
これを関数にします。
# 経緯度からスクリーン座標に変換する関数 def calc_screen_coord( target_lat, target_lon, lat_min, lat_max, lon_min, lon_max, screen_width, screen_height, ): lat_ratio = screen_height / (lat_max - lat_min) lon_ratio = screen_width / (lon_max - lon_min) screen_x = screen_width / 2 - (lon_max - target_lon) * lon_ratio screen_y = screen_height / 2 - (lat_max - target_lat) * lat_ratio return screen_x, screen_y
また、市区町村の位置に文字を表示させる関数は以下の通りです。
marker = turtle.Turtle() marker.penup() marker.hideturtle() # 市区町村の位置に文字を表示する関数 def mark_location(row: pandas.Series): screen_x, screen_y = calc_screen_coord( row["緯度"], row["経度"], lat_min, lat_max, lon_min, lon_max, map_width, map_height, ) marker.goto(screen_x, screen_y) marker.write(row["市区町村名"], align="center", font=("Arial", 7, "normal"))
引数には、ユーザが当てた市区町村のデータが渡されます。先ほど作成した関数でスクリーン座標を取得した後、turtleを使って市区町村名を描画します。
クイズに使用するデータの読み込み部分のコードは以下のようになります。
from PIL import Image import turtle import pandas import tkinter.messagebox # 地図ファイル名と表示サイズ map_file = "map.png" with Image.open(map_file) as map_img: width, height = map_img.size # 画像ファイルのサイズぴったりだと少し画像が見切れるので、余裕を持たせる map_width = width + 5 map_height = height + 5 # スクリーンの設定 screen = turtle.Screen() screen.title("The Road to Aichi Master") screen.setup(map_width, map_height) screen.bgpic(map_file) # ファイル読み込み boundaries = pandas.read_csv("./data/output/boundary_coordinates.csv") center_coordinates = pandas.read_csv("./data/output/center_coordinates.csv") # 境界経緯度を取得 lat_min = boundaries[boundaries["方角"] == "S"]["緯度"].item() lat_max = boundaries[boundaries["方角"] == "N"]["緯度"].item() lon_min = boundaries[boundaries["方角"] == "W"]["経度"].item() lon_max = boundaries[boundaries["方角"] == "E"]["経度"].item()
クイズ部分を実装する前に、各市区町村名が正しい位置に表示されるかを確認してみます。
center_coordinates.apply(mark_location, axis=1) turtle.mainloop()
上記のコードを書き、以下のコマンドで実行します。
python main.py
このようになりました。なんとなく良さそうですが、一部おかしなところに表示されている市区町村もあります。座標の求め方が簡易的ですし、ダウンロードした位置情報の分布にも偏りがある可能性があるので、仕方ないです。今回はcenter_coordinates.csv
の値を手動で微修正しました。
最終的にこのような感じになりました。それなりに良い感じです。
クイズ部分の実装は以下のようになります。
turtleのtextinput
を使ってユーザに入力を促します。ユーザが入力した市区町村名が存在するかを読み込んだDataFrameに対して探しにいき、合っていたらmark_location
関数を呼び出して地図上に描画します。
is_game_on = True # ユーザが正解した市区町村名を格納するリスト correct_answers = [] while is_game_on: answer = screen.textinput( title="Do you love Aichi?", prompt="市区町村名を当ててください。" ) # キャンセルを押した場合 if answer is None: is_game_on = False # 既に答えた市区町村名を入力した場合 elif answer in correct_answers: screen.textinput( title=f"You love {answer} too much!", prompt="すでに答えた市区町村名です。別の市区町村名を入力してください。", ) # 答えが正しい場合 elif answer in center_coordinates["市区町村名"].values: row = center_coordinates[center_coordinates["市区町村名"] == answer].iloc[0] mark_location(row) correct_answers.append(answer) # 全市区町村を当てた場合 if len(correct_answers) == len(center_coordinates): tkinter.messagebox.showinfo( title="Congratulations!", message="全市区町村を当てました!愛知県を愛してくれてありがとう!", ) is_game_on = False # screenを閉じる screen.bye()
おわりに
pandasやturtleの機能を使った簡単なクイズアプリを作ってみました。
pandasはまだまだ勉強中ですが、直感的にデータを扱うことができて使いやすさを実感しています。データ分析の分野で活用されているのも頷けます。
turtleはPython標準のライブラリでありながら、本格的なコンピュータグラフィックができて楽しいです。
ぜひ、みなさんもお住まいの都道府県版のクイズアプリを作ってみてください。
名古屋で会社説明会を開催します
弊社名古屋オフィスは昨年4月に開設し、移転などもありつつ無事に2年目を迎えることができました。
一緒に名古屋、愛知、東海地方を盛り上げてくれる仲間を絶賛募集しています。というわけで、今年もオフラインの会社説明会を行います。
【5/10(金) 名古屋】クラスメソッドグループの会社説明会を開催します! | DevelopersIO
愛知県にお住まいの方や、GWに併せて愛知県に帰省している方など、ぜひこの機会にクラスメソッドグループの会社説明会に遊びにきてください!
※こちらのイベントは終了しました。ご参加頂いた方ありがとうございました。
次回の開催をお楽しみに!
出典
- 「地理院地図」(国土地理院)(https://www.gsi.go.jp/)
- 市区町村名のない白地図は、地理院地図(https://maps.gsi.go.jp/#10/34.982753/137.265244/&base=blank&ls=blank&disp=1&vs=c1g1j0h0k0l0u0t0z0r0s0m0f0&d=m)を加工して作成
- 「位置参照情報ダウンロードサービス」(国土交通省)(https://nlftp.mlit.go.jp/cgi-bin/isj/dls/_choose_method.cgi)